Skip to content

Add vMCP core/server config derivation helpers#5513

Open
tgrunnagle wants to merge 4 commits into
mainfrom
vmcp-core-p3-1_issue_5444
Open

Add vMCP core/server config derivation helpers#5513
tgrunnagle wants to merge 4 commits into
mainfrom
vmcp-core-p3-1_issue_5444

Conversation

@tgrunnagle

@tgrunnagle tgrunnagle commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

The single in-memory server.Config is the last god-config coupling the vMCP domain core
to the transport. Phase 1 (RFC THV-0076) extracted a stateless core.Config, and Phase 2
re-homed every transport concern under a ServerConfig consumed by Serve — but
server.New still threads one monolithic config through both layers. This PR (Phase 3.1)
adds the two derivation helpers that map server.Config onto that core/transport pair,
setting up the #5445 wrapper that will reduce server.New to
Serve(ctx, core.New(deriveCoreConfig(cfg, …)), deriveServerConfig(cfg, …)).

Two new unexported derivation helpers:

  • deriveServerConfig projects the transport-only fields (Host, Port,
    EndpointPath, SessionTTL, the auth/rate-limit middleware handlers, AuthServer,
    status reporting, watcher, session storage) plus the cross-cutting
    TelemetryProvider/AuditConfig onto a fresh ServerConfig, taking the pre-built
    health monitor, backend registry, and session-manager factory config as parameters. It
    is a pure projection (see defaulting below).
  • deriveCoreConfig assembles the core.Config from the cross-cutting fields plus the
    collaborators (aggregator, router, backend client/registry, workflow defs, authz config,
    elicitation requester, health status provider) the composition root builds.

Transport defaulting consolidated to the edge (review Option 1). Following review
feedback, the cmp.Or transport-default list — previously duplicated across server.New
(which mutated its caller's config in place), buildServeConfig, and deriveServerConfig
— is consolidated into a single resolver, WithDefaults, applied once at the composition
root (cli/serve.go). The projection helpers (deriveServerConfig, buildServeConfig)
are now pure pass-through, and server.New resolves defaults on a copy so it no longer
mutates its caller. That non-mutation removes a carry-forward gotcha for #5445: the raw,
un-defaulted cfg.Name now survives for Cedar authz parity instead of being overwritten
with the synthetic toolhive-vmcp default.

No serialized/CRD/YAML format changes; this is an in-memory-only refactor. The assembled
server's runtime behavior is unchanged — the same defaults are applied, just resolved once
at the edge.

Closes #5444

Type of change

  • Refactoring (no behavior change)

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

pkg/vmcp/server, pkg/vmcp/core, and pkg/vmcp/cli pass cleanly, including under
-race; the full task test run is green.

  • derive_test.go (7 tests): verbatim transport-field projection, nil cross-cutting
    propagation (disabled subsystems stay nil), collaborator pass-through by identity for the
    core config, raw-ServerName use with nil admission/health defaults, read-only treatment
    of the input cfg, and reflection-based drift guards asserting every field of both output
    configs is populated.
  • server_test.go: TestWithDefaults covers the defaulting matrix (all defaults /
    explicit-preserved / partial / Port never defaulted) and TestWithDefaultsDoesNotMutateInput
    asserts the resolver does not mutate its argument. The existing server.New defaulting
    tests are unchanged — it still resolves defaults, now via the shared resolver.

task lint-fix is clean for the changed files; the only flag is a pre-existing gosec G115
in cmd/thv/app/upgrade.go, unrelated.

Changes

File Change
pkg/vmcp/server/derive.go New WithDefaults resolver and deriveServerConfig / deriveCoreConfig (pure projections)
pkg/vmcp/server/server.go server.New resolves defaults on a copy via WithDefaults (no caller mutation)
pkg/vmcp/server/serve.go buildServeConfig is now a pure pass-through
pkg/vmcp/cli/serve.go Resolve transport defaults once at the edge via WithDefaults
pkg/vmcp/server/derive_test.go Unit tests for both helpers, including drift guards
pkg/vmcp/server/server_test.go TestWithDefaults + no-mutation test
pkg/vmcp/server/serve_test.go Serve fixtures resolve SessionTTL; drop obsolete defaulting test
pkg/vmcp/server/serve_session_test.go Serve fixtures set SessionTTL

Does this introduce a user-facing change?

No.

Special notes for reviewers

The issue's illustrative code predates the final Phase 1/2 types, so the helpers follow the
actual code rather than the issue's snippet. Intentional divergences:

  • deriveCoreConfig drops the issue's discoveryMgr param. core.Config has no
    discovery field — the core replaces discovery's aggregation role — so the helper instead
    populates Aggregator/Authz/ServerName/Elicitation/HealthStatusProvider.
    ServerName uses the raw cfg.Name (not the transport default) so authorization keys on
    the real VirtualMCPServer name rather than the synthetic toolhive-vmcp fallback.
    Authz/ServerName validation is deferred to core.New, not defaulted here.

  • The legacy SessionFactory/OptimizerFactory/OptimizerConfig fields fold into the
    injected *sessionmanager.FactoryConfig
    (per P2.2 Move SDK hooks + two-phase session creation under Serve #5440). deriveServerConfig takes that,
    the pre-built *health.Monitor (A2), and the shared BackendRegistry as parameters
    because server.Config does not carry them; only the composition root can build the
    factory config (its ComposerFactory closes over the core-owned router/backend client).

  • AuthzMiddleware is intentionally not projected. ServerConfig has no such field —
    authorization moved to the core admission seam (P1.5 Core admission seam (bounded rewrite) #5438), which derives its authorizer from
    the Authz config. The vestigial server.Config.AuthzMiddleware field is kept on purpose;
    the dead HTTP authz blocks that read it are removed later in P3.2 Reduce server.New body to the wrapper #5445.

Option 1 scope note. This PR now also touches server.New and cli/serve.go, which the
issue originally scoped as "unchanged" — a deliberate deviation adopting reviewer feedback
(consolidate transport defaulting at the edge). server.New keeps defaulting defensively
(via the shared WithDefaults, not a duplicated list) rather than becoming strictly pure,
because full purity would require resolving SessionTTL at ~30 legacy server.New test
sites (a zero TTL panics the session-manager cleanup ticker) and #5445 deletes
server.New's body anyway — so the durable wins (one default list, pure projections, no
caller mutation) land now without throwaway churn.

Design decision — separate config types are intentional (Option 2 declined). A reviewer
asked whether the separate core.Config/ServerConfig types plus derive helpers are worth
it versus one shared config (core.Config is mostly injected collaborators). We are keeping
the split: the two-type boundary is what lets the core be constructed and tested with no
transport concerns, and the drift-guarded derive helpers are the single mapping point.
Collapsing to one shared config would re-couple the layers this epic is decomposing.

This is the first half of Phase 3; it blocks #5445 (P3.2, the server.New reduction).
Parent story #5432; epic #5419.


🤖 Generated with Claude Code

Phase 3.1 of the RFC THV-0076 config decomposition. The single in-memory
server.Config is the last god-config that couples the domain core to the
transport. Introduce two unexported helpers that map it onto the already-
existing core.Config (Phase 1) and transport ServerConfig (Phase 2):

- deriveServerConfig projects the transport-only fields plus the
  cross-cutting TelemetryProvider/AuditConfig (R3); the built health
  monitor (A2), shared backend registry, and assembled session-manager
  FactoryConfig are threaded in by the composition root since
  server.Config does not carry them. AuthzMiddleware is not projected —
  ServerConfig has no such field; authz moved to the core admission seam.
- deriveCoreConfig assembles core.Config from cfg's cross-cutting fields
  (ServerName uses the raw cfg.Name for authz parity) plus the
  collaborators passed through rather than reached out of cfg.

Both treat cfg as read-only (no defaulting write-back like server.New).
This only adds the helpers and their tests; #5445 rewires server.New to
call them through Serve. server.New and all serialized formats are
unchanged.

Closes #5444

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jun 12, 2026
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.07843% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.71%. Comparing base (2ddb9c8) to head (6d529d7).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/cli/serve.go 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5513      +/-   ##
==========================================
+ Coverage   69.68%   69.71%   +0.02%     
==========================================
  Files         645      646       +1     
  Lines       65590    65633      +43     
==========================================
+ Hits        45708    45757      +49     
+ Misses      16535    16528       -7     
- Partials     3347     3348       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-agent review summary

Recommendation: COMMENT — clean refactor, 0 blocking issues. All findings are LOW/INFO test-quality polish or forward-looking notes for #5445. (Posted as COMMENT rather than APPROVE because GitHub does not permit approving one's own PR.)

Phase 3.1 of the vMCP config decomposition (RFC THV-0076). Two unexported helpers — deriveServerConfig and deriveCoreConfig — map the legacy monolithic server.Config onto the new core.Config (domain) + ServerConfig (transport) pair, with 8 unit tests including reflection-based drift guards. Purely additive and in-memory-only: the helpers are not yet wired into server.New (#5445 will do that), so there is no observable behavior change.

Four specialist reviewers (Go conventions, architecture/anti-patterns, test coverage, security/authz) independently verified the mappings are complete — all 20 ServerConfig fields and all 11 core.Config fields are populated — that the cmp.Or defaulting matches server.New exactly (and intentionally leaves Port undefaulted), that cfg is treated as read-only (unlike server.New, which writes defaults back), and that Cedar authz parity is preserved by keying core.Config.ServerName on the raw VirtualMCPServer name rather than the synthetic toolhive-vmcp default. No correctness or security defects were found.

Consensus findings

# Severity Category Finding
1 LOW Test AuthMiddleware/RateLimitMiddleware are only NotNil-checked; a swap between the two same-typed fields would not be caught
2 LOW Test *MapsAllFields drift guard overstates its guarantee for cmp.Or-defaulted fields; skip-set / zero-valid Port handling under-documented; an idempotence parity test would help
3 INFO Security Forward-looking note for #5445: pass the raw cfg.Name to deriveCoreConfig (server.New mutates it in place) and preserve nil-Authz allow-all parity

No HIGH or MEDIUM findings. The change satisfies every acceptance criterion in #5444.

Codex cross-review: skipped (codex CLI not installed).

🤖 Multi-agent review via /dev:pr-review

Comment thread pkg/vmcp/server/derive_test.go
Comment thread pkg/vmcp/server/derive_test.go
Comment thread pkg/vmcp/server/derive.go
@tgrunnagle tgrunnagle marked this pull request as ready for review June 12, 2026 19:07
jerm-dro
jerm-dro previously approved these changes Jun 12, 2026

@jerm-dro jerm-dro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: These are strictly options — the helpers are correct and the *MapsAllFields reflection guards are a solid safety net against silent drift. A couple of directions that could reduce the moving parts, take or leave:

Option 1 — pull defaulting strictly upstream. Right now the cmp.Or transport defaults live in three places: server.New, buildServeConfig, and deriveServerConfig. If the config is fully resolved once at the edge (cli/serve.go, where flags/CRD/YAML become config), then every projection downstream is pure pass-through — no cmp.Or, no duplicated "which fields get defaulted" list, no idempotent re-defaulting. The config types would then hold resolved values, which is easier to reason about than "defaulted depending on which constructor you came through."

Option 2 — reconsider whether the split needs separate types + derivation code. core.New and Serve are already separate functions, so a caller naturally only supplies what each needs — the boundary is enforced by what you pass each function. That raises the question of whether two config types (plus deriveCoreConfig/deriveServerConfig to convert between them, plus the server.Config → ServerConfig → Config round-trip) are buying enough over one shared config that both reference. Worth noting core.Config is ~8/11 injected collaborators and only 3 actual config values, so it's close to a collaborator bag core.New could take directly. If the split is still wanted, expressing it on the existing config (e.g. embedded sub-structs) would get the same separation without new conversion code or parallel transport configs that can drift.

Both are bigger than this diff and partly touch the earlier-phase types, so deferring the split entirely is also a reasonable call. I'll leave this up to you.

Addresses PR review (Option 1): the cmp.Or transport defaults previously
lived in three projection functions (server.New, buildServeConfig,
deriveServerConfig) with a duplicated "which fields" list, and server.New
applied them by mutating the caller's Config in place.

Consolidate to a single resolver, WithDefaults, and resolve once at the
composition root (cli/serve.go):

- WithDefaults is now the only place the transport default list lives.
- deriveServerConfig and buildServeConfig become pure pass-through.
- server.New calls WithDefaults on a COPY, so it no longer mutates the
  caller's Config. That non-mutation lets #5445 hand the raw, un-defaulted
  cfg.Name to the core for Cedar authz parity (it removes the carry-forward
  gotcha that #5445 would otherwise have to work around). server.New keeps
  defaulting defensively until #5445 reduces it to a Serve wrapper.

Serve is now a pure pass-through, so its test fixtures resolve SessionTTL
explicitly (a zero TTL fails session-storage construction). Defaulting
behavior is covered by TestWithDefaults; the obsolete
TestServeAppliesTransportDefaults / TestDeriveServerConfigAppliesTransportDefaults
are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tgrunnagle

Copy link
Copy Markdown
Contributor Author

@jerm-dro thanks for the review. Adopted Option 1 in 6d529d7.

Transport defaulting now lives in a single resolver, WithDefaults, and is resolved once at the composition root (cli/serve.go):

  • deriveServerConfig and buildServeConfig are now pure pass-through (no cmp.Or).
  • server.New calls WithDefaults on a copy, so it no longer mutates the caller's Config. That non-mutation removes the raw-cfg.Name carry-forward gotcha P3.2 Reduce server.New body to the wrapper #5445 would otherwise have had to work around (Cedar authz keys on the real name, not the synthetic default).

One pragmatic scoping choice: server.New keeps defaulting defensively (via the shared WithDefaults, not a duplicated list) rather than becoming strictly pure. Making it fully pure would force resolving SessionTTL at ~30 legacy-path server.New test sites (a 0 TTL now panics the session-manager ticker) — and #5445 deletes server.New's body anyway, so that churn would be throwaway. The durable wins (one default list, pure projections, no caller mutation) all land now.

On Option 2 (whether the split needs separate types + derivation): fair point that core.Config is mostly collaborators. Deferring that decision — it touches the Phase 1/2 types — but it's worth settling before #5445 wires these helpers in, so we don't refine code we might collapse. Tracked for that task.

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jun 15, 2026
jerm-dro
jerm-dro previously approved these changes Jun 15, 2026
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 15, 2026
"Option 1" referred to a PR-review discussion thread and is meaningless
to future readers of the code. Reword the comments to keep the substance
(transport defaults resolved once at the composition root via
WithDefaults) without the review-specific label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 15, 2026
WithDefaults is config plumbing of the same family as deriveServerConfig
and deriveCoreConfig (resolve/decompose the legacy Config at the
composition-root edge), so it belongs alongside them rather than in the
already-oversized server.go. No behavior change; cmp moves with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tgrunnagle tgrunnagle requested a review from jerm-dro June 15, 2026 17:52
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 15, 2026
@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P3.1 deriveCoreConfig/deriveServerConfig config split

2 participants